Skip to content

Conversation

@brozorec
Copy link
Collaborator

@brozorec brozorec commented Jan 14, 2026

Fixes #437
Fixes #439

PR Checklist

  • Tests
  • Documentation

Summary by CodeRabbit

Release Notes

  • New Features
    • Added voting module with delegation, checkpoints, and historical vote tracking capabilities
    • Added voting extensions for fungible and non-fungible tokens
    • Added example contract demonstrating fungible token voting
    • Updated governance documentation with voting concepts and timelock usage details

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 14, 2026

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

  • 🔍 Trigger a full review

Walkthrough

Introduces a comprehensive voting system for the Soroban ecosystem with delegation, checkpointing, and voting unit tracking. Adds governance votes module with historical vote queries, fungible and non-fungible token voting extensions integrating with voting infrastructure, and an example contract demonstrating usage.

Changes

Cohort / File(s) Summary
Governance Voting Core
packages/governance/src/lib.rs, packages/governance/src/votes/mod.rs, packages/governance/src/votes/storage.rs, packages/governance/src/votes/test.rs
Introduces votes module with contract trait Votes exposing delegation and historical vote queries. Storage layer implements checkpoint-based vote tracking, binary search for past votes, delegation state management, and voting unit transfers. Test module provides comprehensive coverage of voting mechanics, delegation, transfers, and edge cases.
Fungible Token Voting Extension
packages/tokens/src/fungible/extensions/mod.rs, packages/tokens/src/fungible/extensions/votes/mod.rs, packages/tokens/src/fungible/extensions/votes/storage.rs, packages/tokens/src/fungible/extensions/votes/test.rs
Adds FungibleVotes extension implementing ContractOverrides to integrate voting unit tracking into mint, burn, transfer, and transfer_from operations. Storage delegates token operations to Base while synchronizing voting units. Tests verify voting unit updates across delegation scenarios.
Non-Fungible Token Voting Extension
packages/tokens/src/non_fungible/extensions/mod.rs, packages/tokens/src/non_fungible/extensions/votes/mod.rs, packages/tokens/src/non_fungible/extensions/votes/storage.rs, packages/tokens/src/non_fungible/extensions/votes/test.rs
Adds NonFungibleVotes extension for NFT voting with voting weight of 1 unit per NFT. Implements ContractOverrides for transfer and transfer_from, plus mint, sequential_mint, burn, and burn_from with voting unit synchronization. Test coverage includes multi-NFT delegation and voting aggregation.
Example Contract
examples/fungible-votes/...
New fungible-votes example demonstrating contract lifecycle with owner-controlled mint, FungibleToken, Votes, and Ownable trait implementations. Includes Cargo.toml configuration and lib.rs setup.
Module Exports & Dependencies
packages/governance/README.md, packages/tokens/src/fungible/mod.rs, packages/tokens/src/non_fungible/mod.rs, packages/tokens/Cargo.toml, Cargo.toml
Updates public re-exports to expose votes extensions. Documents Votes subsection with Core Concepts and Features. Expands Timelock documentation with usage examples. Adds stellar-governance workspace dependency and fungible-votes workspace member.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Account as Account Storage
    participant Votes as Votes Module
    participant Delegate as Delegate Tracking
    participant Checkpoints as Checkpoint Storage
    participant History as Historical Queries

    User->>Votes: delegate(account, delegatee)
    Votes->>Account: verify authorization
    Votes->>Delegate: update delegatee reference
    Votes->>Checkpoints: push checkpoint for old delegate
    Votes->>Checkpoints: push checkpoint for new delegate
    Votes->>User: emit DelegateChanged

    User->>Votes: transfer_voting_units(from, to, amount)
    Votes->>Delegate: get old_delegate (from)
    Votes->>Delegate: get new_delegate (to)
    Votes->>Checkpoints: update old_delegate checkpoint
    Votes->>Checkpoints: update new_delegate checkpoint
    Votes->>User: emit DelegateVotesChanged

    User->>History: get_past_votes(account, timepoint)
    History->>Checkpoints: binary search checkpoint at timepoint
    History->>User: return historical voting power
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~70 minutes

Poem

🐰 Hop-hop, voting power's here!
Delegation tracked with checkpoint cheer,
From fungible coins to NFTs bright,
Voting units sync both day and night! 🗳️✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Governance: Votes' is concise and accurately describes the main feature introduced in the PR—a votes module for governance.
Description check ✅ Passed The PR description references fixed issues (#437 and #439) and marks checklist items as complete, meeting the template requirements despite minimal additional context.
Linked Issues check ✅ Passed The code changes comprehensively implement voting power tracking [#437], delegation with checkpoints, and both fungible and non-fungible token voting extensions [#439].
Out of Scope Changes check ✅ Passed All changes are directly aligned with implementing the votes module, governance extensions, and supporting infrastructure with no unrelated modifications.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch votes

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link

codecov bot commented Jan 27, 2026

Codecov Report

❌ Patch coverage is 94.38596% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.00%. Comparing base (ab67189) to head (1ecdc7d).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...es/tokens/src/fungible/extensions/votes/storage.rs 83.33% 6 Missing ⚠️
...okens/src/non_fungible/extensions/votes/storage.rs 80.64% 6 Missing ⚠️
packages/governance/src/votes/storage.rs 98.16% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #552      +/-   ##
==========================================
- Coverage   96.08%   96.00%   -0.09%     
==========================================
  Files          54       57       +3     
  Lines        5215     5500     +285     
==========================================
+ Hits         5011     5280     +269     
- Misses        204      220      +16     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@brozorec brozorec marked this pull request as ready for review January 28, 2026 10:59
@brozorec brozorec requested a review from ozgunozerk January 28, 2026 10:59
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/governance/src/votes/storage.rs`:
- Around line 258-312: The transfer_voting_units function currently calls
move_delegate_votes twice (once for sender and once for receiver), which emits
misleading zero-net events when both accounts delegate to the same address;
change the flow to first compute from_delegate = get_delegate(e, from_addr) and
to_delegate = get_delegate(e, to_addr) (for Option cases) and then invoke
move_delegate_votes once with those delegate Option<&Address> values so its
internal from==to short-circuit prevents redundant events; ensure you still
perform voting unit arithmetic (get_voting_units, checked_sub/checked_add,
set_voting_units) and still call push_total_supply_checkpoint for mint/burn
paths, but move the delegate lookup so a single call to move_delegate_votes
replaces the two separate calls.
🧹 Nitpick comments (3)
packages/governance/README.md (1)

118-121: Consider adding the fungible-votes example.

The Examples section only references the timelock-controller example. Consider adding a reference to the new examples/fungible-votes/ example to showcase the Votes module usage.

📝 Suggested addition
 ## Examples
 
 See the following examples in the repository:
+- [`examples/fungible-votes/`](https://github.com/OpenZeppelin/stellar-contracts/tree/main/examples/fungible-votes) - Fungible token with voting power delegation
 - [`examples/timelock-controller/`](https://github.com/OpenZeppelin/stellar-contracts/tree/main/examples/timelock-controller) - Timelock controller with role-based access control
examples/fungible-votes/src/contract.rs (1)

1-8: Unused import: MuxedAddress.

The MuxedAddress type is imported but not used in this file.

♻️ Suggested fix
-use soroban_sdk::{contract, contractimpl, Address, Env, MuxedAddress, String};
+use soroban_sdk::{contract, contractimpl, Address, Env, String};
packages/governance/src/votes/storage.rs (1)

316-324: Add extend_ttl after persistent storage writes to prevent premature expiration of voting data.

The codebase consistently calls extend_ttl after .get() operations (visible in get_delegate, get_voting_units, get_checkpoint, get_total_supply_checkpoint), but does not call it after .set() operations in set_voting_units, delegate, or checkpoint creation/updates. Since .set() initializes entries with the network minimum TTL, there's a window where newly written data could expire before being read and extended. For critical historical voting data, extend TTL immediately after writes to ensure consistent persistence.

♻️ Example tweak for this helper
 fn set_voting_units(e: &Env, account: &Address, units: u128) {
     let key = VotesStorageKey::VotingUnits(account.clone());
     if units == 0 {
         e.storage().persistent().remove(&key);
     } else {
         e.storage().persistent().set(&key, &units);
+        e.storage().persistent().extend_ttl(&key, VOTES_TTL_THRESHOLD, VOTES_EXTEND_AMOUNT);
     }
 }

Apply the same pattern to delegate() and checkpoint creation in push_checkpoint and push_total_supply_checkpoint.

Comment on lines +25 to +26
//! This module provides storage functions that can be integrated into a token
//! contract. The contract is responsible for:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would rephrase here. This module is not only consisting of storage.rs. It also provides the trait, etc.

//! delegate, get_votes, get_past_votes, transfer_voting_units,
//! };
//!
//! // In your token contract transfer:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should make here more clear. For example, override the transfer method as shown in below. From this paragraph alone, developer may not understand what to do

///
/// * `e` - Access to the Soroban environment.
/// * `account` - The address to query voting power for.
fn get_votes(e: &Env, account: Address) -> u128 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is great! I wanted to get rid of get_votes in the actual governor trait interface. I think it belongs here.

If this wasn't accidental, and if you are ok with removing get_votes from governor interface to make it slimmer, let's keep it like this.

Open for discussions, because I've made a lot of changes to the governor interface to make it more compact

///
/// * `e` - Access to the Soroban environment.
/// * `account` - The address to query voting power for.
fn get_votes(e: &Env, account: Address) -> u128 {
Copy link
Collaborator

@ozgunozerk ozgunozerk Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we mention this will return 0 for addresses that do not exist (not a participant of this contract)? Because I don't see any error related to that. The same for other similar functions in the below

//!
//! This module follows the design of OpenZeppelin's Solidity `Votes.sol`:
//! - Voting units must be explicitly delegated to count as votes
//! - Self-delegation is required for an account to use its own voting power
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, highlighting only delegatees can vote here would be good, and also repeating it in storage.rs. I'll mention it in a comment there

pub votes: u128,
}

/// Storage keys for the votes module.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"only delegatees can vote, so this storage key is designed around that principle" kind of reminder would be helpful. I got confused whilst reading this storage key, and then I remembered I read something similar in the mod.rs.

///
/// This is the total voting power delegated to this account by others
/// (and itself if self-delegated).
///
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, I think it is important here (and maybe in the corresponding trait method as well), this will return 0, if the voting power has not delegated to anyone (including self)

/// # Errors
///
/// * [`VotesError::FutureLookup`] - If `timepoint` >= current timestamp.
fn get_past_votes(e: &Env, account: Address, timepoint: u64) -> u128 {
Copy link
Collaborator

@ozgunozerk ozgunozerk Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest renaming this to get_votes_at_checkpoint. Will be much more clear. The same for the storage function

get_checkpoint(e, account, num - 1).votes
}

/// Returns the voting power of an account at a specific past timestamp.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

timestamp or timepoint, should be consistent

Comment on lines +140 to +165
// Check if timepoint is after the latest checkpoint
let latest = get_total_supply_checkpoint(e, num - 1);
if latest.timestamp <= timepoint {
return latest.votes;
}

// Check if timepoint is before the first checkpoint
let first = get_total_supply_checkpoint(e, 0);
if first.timestamp > timepoint {
return 0;
}

// Binary search
let mut low: u32 = 0;
let mut high: u32 = num - 1;

while low < high {
let mid = (low + high).div_ceil(2);
let checkpoint = get_total_supply_checkpoint(e, mid);
if checkpoint.timestamp <= timepoint {
low = mid;
} else {
high = mid - 1;
}
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is duplicated also in get_past_votes, maybe we can re-use this code piece in an helper function

get_past_votes(e, &account, timepoint)
}

/// Returns the total supply of voting units at a specific past timestamp.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused about this one. Is this also only accounting for the delegated votes? Should be... But wanted to double check

/// # Notes
///
/// Authorization for `account` is required.
pub fn delegate(e: &Env, account: &Address, delegatee: &Address) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this currently allows for specifying the same delegate, and overriding it with itself.

I don't think it is a security issue, but better to give an error to eliminate possible wrong transactions

set_voting_units(e, from_addr, new_from_units);
} else {
// Minting: increase total supply
push_total_supply_checkpoint(e, true, amount);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd advice against having a bool that represents add or subtract.

May sound like an overkill, but it's not, let's create an enum for that and pass that concrete type here. Would be more readable and prevent possible bugs in the future

Copy link
Collaborator

@ozgunozerk ozgunozerk Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variants could be burn and mint

Copy link
Collaborator

@ozgunozerk ozgunozerk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Liked the overall structure. Few changes are requested. All of them are fairly minimal.

Only reviewed the half of it. Will continue to review tomorrow. Reminder to myself: push_checkpoint

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fungible and Non-Fungible Votes Extensions Votes

3 participants